<template>
  <div class="page-line-example">
    <h1>page 分页示例</h1>

    <p class="hint">
      schema 仅包含 document → paragraph → text。自定义 paragraph NodeView 会在渲染阶段拆分行，
      生成 page/line 派生节点，仅用于渲染与分页，不会写入文档 JSON。
    </p>

    <div v-if="editor" class="page-editor" :style="pageStyleVars">
      <div class="page-overlay">
        <div
          v-for="(page, pageIndex) in pages"
          :key="page.id"
          class="page"
          :style="{ top: page.top + 'px' }"
        >
          <div class="page-lines">
            <div
              v-for="paragraph in page.paragraphs"
              :key="paragraph.id"
              class="page-paragraph"
              :style="{
                top: paragraph.top + 'px',
                height: paragraph.height + 'px'
              }"
            >
              <div
                v-for="line in paragraph.lines"
                :key="line.id"
                class="page-line"
                :class="{
                  'page-line--gap': line.role === 'gap',
                  'page-line--first': line.isFirstInParagraph && line.role === 'content'
                }"
                :style="{
                  top: line.top + 'px',
                  height: line.height + 'px'
                }"
              ></div>
            </div>
          </div>
          <footer class="page-footer">Page {{ pageIndex + 1 }}</footer>
        </div>
      </div>

      <EditorContent :editor="editor" class="editor-surface" />
    </div>

    <div class="actions">
      <button @click="logJson" :disabled="!editor">打印文档 JSON</button>
      <button @click="logHtml" :disabled="!editor">打印文档 HTML</button>
      <button @click="clearOutput" :disabled="!output">清空输出</button>
    </div>

    <pre class="output" v-if="output">{{ output }}</pre>
  </div>
</template>

<script>
import { Editor, EditorContent } from '@tiptap/vue-2'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Extension } from '@tiptap/core'

const BASE_PAGE_SIZE = {
  width: 620,
  height: 820
}

const BASE_PAGE_PADDING = {
  horizontal: 48,
  top: 40,
  bottom: 56
}

const BASE_PAGE_GAP = 32

const BASE_METRICS = {
  fontSize: 14,
  lineHeight: 22.4,
  fontFamily: '"PingFang SC", "Microsoft YaHei", Arial, sans-serif',
  paragraphSpacing: 12,
  firstLineIndentEm: 2
}

function createPageOptions() {
  const pageSize = { ...BASE_PAGE_SIZE }
  const pagePadding = { ...BASE_PAGE_PADDING }
  const metrics = { ...BASE_METRICS }
  const pageGap = BASE_PAGE_GAP
  const contentWidth = pageSize.width - pagePadding.horizontal * 2
  const pageContentHeight = pageSize.height - pagePadding.top - pagePadding.bottom

  return {
    pageSize,
    pagePadding,
    pageGap,
    metrics,
    contentWidth,
    pageContentHeight
  }
}

function createMeasurementContext(metrics) {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')
  if (!ctx) {
    return null
  }
  ctx.font = `${metrics.fontSize}px ${metrics.fontFamily}`
  return ctx
}

function breakTextIntoLines(ctx, text, maxWidth, firstLineIndentPx = 0) {
  if (!ctx) {
    const fallback = text.split('\n')
    if (!fallback.length) {
      return [{ text: '', length: 0 }]
    }
    return fallback.map(part => ({ text: part, length: part.length }))
  }

  const glyphs = Array.from(text)
  const lines = []
  let buffer = ''
  let glyphCount = 0
  let isFirstLine = true
  let firstLineLimit = maxWidth - firstLineIndentPx
  if (firstLineLimit <= 0) {
    firstLineLimit = maxWidth
  }
  let currentLimit = isFirstLine ? firstLineLimit : maxWidth

  const pushLine = () => {
    lines.push({ text: buffer, length: glyphCount })
    buffer = ''
    glyphCount = 0
    isFirstLine = false
    currentLimit = maxWidth
  }

  glyphs.forEach(glyph => {
    if (glyph === '\n') {
      pushLine()
      isFirstLine = false
      currentLimit = maxWidth
      return
    }

    const next = buffer + glyph
    const width = ctx.measureText(next).width

    if (width <= currentLimit || !buffer) {
      buffer = next
      glyphCount += 1
    } else {
      pushLine()
      buffer = glyph
      glyphCount = 1
      isFirstLine = false
      currentLimit = maxWidth
    }
  })

  if (buffer || !lines.length) {
    pushLine()
  }

  return lines
}

function composePages(layoutSource, layoutOptions) {
  const entries = layoutSource instanceof Map
    ? Array.from(layoutSource.entries())
    : Array.isArray(layoutSource)
      ? layoutSource
      : []

  if (!entries.length) {
    const emptyPage = {
      type: 'page',
      id: 'page-0',
      index: 0,
      top: 0,
      height: layoutOptions.pageSize.height,
      paragraphs: []
    }
    return {
      pages: [emptyPage],
      contentHeight: layoutOptions.pageSize.height
    }
  }

  const pages = []
  const makePage = index => ({
    type: 'page',
    id: `page-${index}`,
    index,
    top: index * (layoutOptions.pageSize.height + layoutOptions.pageGap),
    height: layoutOptions.pageSize.height,
    paragraphs: []
  })

  let currentPage = makePage(0)
  pages.push(currentPage)
  let cursor = 0
  let pageIndex = 0
  let paragraphIndex = 0

  const startNewPage = () => {
    pageIndex += 1
    currentPage = makePage(pageIndex)
    pages.push(currentPage)
    cursor = 0
  }

  entries.forEach(([, layout], entryIndex) => {
    const lines = Array.isArray(layout?.lines) ? layout.lines : []
    let segment = null
    let segmentIndex = 0
    let lineOffsetWithinSegment = 0

    const finalizeSegment = () => {
      if (!segment) {
        return
      }
      segment.height = lineOffsetWithinSegment
      segment = null
      lineOffsetWithinSegment = 0
    }

    const ensureSegment = () => {
      if (segment) {
        return
      }
      segment = {
        type: 'renderParagraph',
        id: `renderParagraph-${paragraphIndex}-${segmentIndex}`,
        paragraphIndex,
        index: segmentIndex,
        top: cursor,
        height: 0,
        lines: []
      }
      currentPage.paragraphs.push(segment)
      segmentIndex += 1
      lineOffsetWithinSegment = 0
    }

    lines.forEach((line, lineIndex) => {
      const lineHeight = typeof line.height === 'number' ? line.height : layoutOptions.metrics.lineHeight
      const needNewPage = cursor + lineHeight > layoutOptions.pageContentHeight

      if (needNewPage) {
        if (segment && segment.lines.length) {
          finalizeSegment()
        }
        if (currentPage.paragraphs.length) {
          startNewPage()
        }
        ensureSegment()
      } else {
        ensureSegment()
      }

      const lineTop = lineOffsetWithinSegment
      const lineNode = {
        type: 'renderLine',
        id: `renderLine-${paragraphIndex}-${segment.index}-${segment.lines.length}`,
        lineIndexWithinParagraph: lineIndex,
        role: 'content',
        text: line.text || '',
        paragraphIndex,
        isFirstInParagraph: segment.lines.length === 0 && line.isFirstInParagraph,
        top: lineTop,
        height: lineHeight
      }

      segment.lines.push(lineNode)
      lineOffsetWithinSegment += lineHeight
      cursor += lineHeight
    })

    const isLastParagraph = entryIndex === entries.length - 1
    const gapHeight = layout?.paragraphSpacing || 0

    if (!isLastParagraph && gapHeight > 0) {
      if (cursor + gapHeight > layoutOptions.pageContentHeight) {
        finalizeSegment()
        if (currentPage.paragraphs.length) {
          startNewPage()
        }
      } else {
        ensureSegment()
        const gapTop = lineOffsetWithinSegment
        segment.lines.push({
          type: 'renderLine',
          id: `renderLine-${paragraphIndex}-${segment.index}-${segment.lines.length}`,
          lineIndexWithinParagraph: segment.lines.length,
          role: 'gap',
          text: '',
          paragraphIndex,
          isFirstInParagraph: false,
          top: gapTop,
          height: gapHeight
        })
        lineOffsetWithinSegment += gapHeight
        cursor += gapHeight
      }
    }

    finalizeSegment()
    paragraphIndex += 1
  })

  const filteredPages = pages.filter(page => page.paragraphs.length > 0)
  if (!filteredPages.length) {
    const fallback = makePage(0)
    return {
      pages: [fallback],
      contentHeight: fallback.height
    }
  }

  const lastPage = filteredPages[filteredPages.length - 1]
  const contentHeight = lastPage.top + layoutOptions.pageSize.height

  return { pages: filteredPages, contentHeight }
}

const DEFAULT_LAYOUT_OPTIONS = createPageOptions()

const PagedParagraph = Paragraph.extend({
  addOptions() {
    return {
      metrics: DEFAULT_LAYOUT_OPTIONS.metrics,
      contentWidth: DEFAULT_LAYOUT_OPTIONS.contentWidth,
      onLayoutChange: null
    }
  },
  addStorage() {
    return {
      pagedLayouts: new Map()
    }
  },
  addNodeView() {
    const extension = this
    return ({ node, getPos }) => {
      const metrics = extension.options.metrics
      const ctx = createMeasurementContext(metrics)
      const firstLineIndentPx = metrics.firstLineIndentEm * metrics.fontSize
      const contentWidth = extension.options.contentWidth

      const wrapper = document.createElement('div')
      wrapper.classList.add('paged-paragraph')

      const overlay = document.createElement('div')
      overlay.classList.add('paged-paragraph__overlay')
      wrapper.appendChild(overlay)

      const content = document.createElement('div')
      content.classList.add('paged-paragraph__content')
      wrapper.appendChild(content)

      let storedPos = null

      const updateLayout = currentNode => {
        const text = currentNode.textContent || ''
        const segments = breakTextIntoLines(ctx, text, contentWidth, firstLineIndentPx)

        overlay.innerHTML = ''
        const layoutLines = []
        let cursorTop = 0

        segments.forEach((segment, index) => {
          const lineHeight = metrics.lineHeight
          const lineEl = document.createElement('div')
          lineEl.className = 'paged-paragraph__line'
          lineEl.style.top = `${cursorTop}px`
          lineEl.style.height = `${lineHeight}px`
          lineEl.dataset.index = String(index)
          overlay.appendChild(lineEl)

          layoutLines.push({
            isGap: false,
            text: segment.text,
            isFirstInParagraph: index === 0,
            height: lineHeight
          })

          cursorTop += lineHeight
        })

        if (!segments.length) {
          const lineHeight = metrics.lineHeight
          const lineEl = document.createElement('div')
          lineEl.className = 'paged-paragraph__line'
          lineEl.style.top = '0px'
          lineEl.style.height = `${lineHeight}px`
          overlay.appendChild(lineEl)

          layoutLines.push({
            isGap: false,
            text: '',
            isFirstInParagraph: true,
            height: lineHeight
          })
        }

        const pos = typeof getPos === 'function' ? getPos() : storedPos
        if (typeof pos !== 'number') {
          return
        }

        if (storedPos !== null && storedPos !== pos) {
          extension.storage.pagedLayouts.delete(storedPos)
        }

        storedPos = pos
        extension.storage.pagedLayouts.set(pos, {
          lines: layoutLines,
          paragraphSpacing: metrics.paragraphSpacing
        })

        if (typeof extension.options.onLayoutChange === 'function') {
          extension.options.onLayoutChange()
        }
      }

      updateLayout(node)

      return {
        dom: wrapper,
        contentDOM: content,
        update(updatedNode) {
          if (updatedNode.type !== node.type) {
            return false
          }
          updateLayout(updatedNode)
          return true
        },
        destroy() {
          if (storedPos !== null) {
            extension.storage.pagedLayouts.delete(storedPos)
            if (typeof extension.options.onLayoutChange === 'function') {
              extension.options.onLayoutChange()
            }
          }
        }
      }
    }
  },
  renderHTML({ HTMLAttributes }) {
    return ['p', HTMLAttributes, 0]
  }
})

const PagedLayout = Extension.create({
  name: 'pagedLayout',
  addOptions() {
    return {
      layoutOptions: DEFAULT_LAYOUT_OPTIONS,
      getParagraphLayouts: null
    }
  },
  addStorage() {
    const { layoutOptions } = this.options
    return {
      pages: [],
      contentHeight: layoutOptions.pageSize.height,
      recompute: () => ({ pages: [], contentHeight: layoutOptions.pageSize.height })
    }
  },
  onCreate() {
    this.storage.recompute = () => {
      const layoutsSource = typeof this.options.getParagraphLayouts === 'function'
        ? this.options.getParagraphLayouts()
        : null
      const { pages, contentHeight } = composePages(layoutsSource, this.options.layoutOptions)
      this.storage.pages = pages
      this.storage.contentHeight = contentHeight
      return {
        pages,
        contentHeight
      }
    }
  }
})

const PlainDocument = Document.extend({
  content: 'paragraph+'
})

export default {
  name: 'Render2View',
  components: {
    EditorContent
  },
  data() {
    const pageOptions = createPageOptions()
    return {
      editor: null,
      output: '',
      pages: [],
      contentHeight: pageOptions.pageSize.height,
      pageOptions,
      layoutRefreshId: null
    }
  },
  computed: {
    pageStyleVars() {
      const { pageSize, pagePadding, pageGap, metrics } = this.pageOptions
      const fallbackHeight = pageSize.height
      const contentHeight = Math.max(this.contentHeight, fallbackHeight)

      return {
        '--page-width': `${pageSize.width}px`,
        '--page-height': `${pageSize.height}px`,
        '--page-gap': `${pageGap}px`,
        '--content-height': `${contentHeight}px`,
        '--page-padding-top': `${pagePadding.top}px`,
        '--page-padding-bottom': `${pagePadding.bottom}px`,
        '--page-padding-horizontal': `${pagePadding.horizontal}px`,
        '--font-size': `${metrics.fontSize}px`,
        '--line-height': `${metrics.lineHeight}px`,
        '--font-family': metrics.fontFamily,
        '--paragraph-spacing': `${metrics.paragraphSpacing}px`,
        '--first-line-indent': `${metrics.firstLineIndentEm}em`
      }
    }
  },
  mounted() {
    const { pageOptions } = this

    this.editor = new Editor({
      extensions: [
        PlainDocument,
        PagedParagraph.configure({
          metrics: pageOptions.metrics,
          contentWidth: pageOptions.contentWidth,
          onLayoutChange: this.requestPageUpdate
        }),
        PagedLayout.configure({
          layoutOptions: pageOptions,
          getParagraphLayouts: () => this.editor?.storage?.paragraph?.pagedLayouts
        }),
        Text
      ],
      content: {
        type: 'doc',
        content: [
          {
            type: 'paragraph',
            content: [
              {
                type: 'text',
                text: '这是一个只包含 paragraph 和 text 节点的简单示例。分页视图通过 NodeView 渲染，派生 page/line 节点仅存在于渲染阶段。'
              }
            ]
          }
        ]
      }
    })

    this.editor.on('update', this.requestPageUpdate)
    this.requestPageUpdate()
  },
  beforeDestroy() {
    if (this.layoutRefreshId !== null) {
      cancelAnimationFrame(this.layoutRefreshId)
      this.layoutRefreshId = null
    }
    if (this.editor) {
      this.editor.off('update', this.requestPageUpdate)
      this.editor.destroy()
    }
  },
  methods: {
    requestPageUpdate() {
      if (this.layoutRefreshId !== null) {
        return
      }
      this.layoutRefreshId = window.requestAnimationFrame(() => {
        this.layoutRefreshId = null
        this.updatePages()
      })
    },
    updatePages() {
      const layoutStore = this.editor?.storage?.pagedLayout
      if (!layoutStore || typeof layoutStore.recompute !== 'function') {
        const fallbackPage = {
          type: 'page',
          id: 'page-0',
          index: 0,
          top: 0,
          height: this.pageOptions.pageSize.height,
          paragraphs: []
        }
        this.pages = [fallbackPage]
        this.contentHeight = this.pageOptions.pageSize.height
        return
      }

      const { pages, contentHeight } = layoutStore.recompute()
      this.pages = pages
      this.contentHeight = contentHeight
    },
    logJson() {
      if (!this.editor) {
        return
      }
      this.output = JSON.stringify(this.editor.getJSON(), null, 2)
    },
    logHtml() {
      if (!this.editor) {
        return
      }
      this.output = this.editor.getHTML()
    },
    clearOutput() {
      this.output = ''
    }
  }
}
</script>

<style scoped>
.page-line-example {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.hint {
  margin: 0;
  color: #666;
  font-size: 13px;
}

.page-editor {
  position: relative;
  width: var(--page-width);
  min-height: var(--content-height);
  margin: 0 auto;
}

.page-overlay {
  position: absolute;
  inset: 0;
  width: var(--page-width);
  height: var(--content-height);
  pointer-events: none;
  margin: 0 auto;
  left: 0;
  right: 0;
  z-index: 0;
}

.page {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  width: var(--page-width);
  height: var(--page-height);
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12);
  box-sizing: border-box;
  overflow: hidden;
}

.page-lines {
  position: absolute;
  inset: var(--page-padding-top) var(--page-padding-horizontal)
    var(--page-padding-bottom);
  pointer-events: none;
}

.page-paragraph {
  position: absolute;
  left: 0;
  right: 0;
}

.page-line {
  position: absolute;
  left: 0;
  right: 0;
  border-bottom: 1px dashed rgba(148, 163, 184, 0.25);
}

.page-line--gap {
  border-bottom: none;
}

.page-line--first {
  border-bottom-style: solid;
  border-bottom-color: rgba(94, 234, 212, 0.35);
}

.page-footer {
  position: absolute;
  right: var(--page-padding-horizontal);
  bottom: 16px;
  font-size: 12px;
  color: #94a3b8;
}

.editor-surface {
  position: relative;
  z-index: 1;
}

.editor-surface .ProseMirror {
  width: var(--page-width);
  min-height: var(--content-height);
  padding: var(--page-padding-top) var(--page-padding-horizontal)
    var(--page-padding-bottom);
  margin: 0 auto;
  box-sizing: border-box;
  font-size: var(--font-size);
  line-height: var(--line-height);
  font-family: var(--font-family);
  background: transparent;
}

.editor-surface .ProseMirror:focus {
  outline: none;
}

.editor-surface .ProseMirror .paged-paragraph {
  position: relative;
  margin: 0 0 var(--paragraph-spacing);
  color: #1f2937;
}

.editor-surface .ProseMirror .paged-paragraph:last-child {
  margin-bottom: 0;
}

.editor-surface .ProseMirror .paged-paragraph__content {
  position: relative;
  z-index: 1;
  white-space: pre-wrap;
  word-break: break-word;
  text-indent: var(--first-line-indent);
}

.editor-surface .ProseMirror .paged-paragraph__overlay {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 0;
}

.editor-surface .ProseMirror .paged-paragraph__line {
  position: absolute;
  left: 0;
  right: 0;
  border-bottom: 1px dashed rgba(148, 163, 184, 0.18);
}

.actions {
  display: flex;
  gap: 12px;
}

.actions button {
  padding: 6px 12px;
  cursor: pointer;
}

.output {
  background: #1e1e1e;
  color: #d6deeb;
  padding: 12px;
  border-radius: 6px;
  max-height: 240px;
  overflow: auto;
  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
  font-size: 12px;
}
</style>
